Here’s the simplest NFT contract:
import '@openzeppelin/contracts/token/ERC721/ERC721.sol';
contract SimplestNFT is ERC721 {
function mint(uint256 tokenId) external {
_safeMint(msg.sender, tokenId);
}
}
It delegates the heavy lifting to the base contract implementation (in this case, OpenZeppelin). _safeMint
is responsible for matching the ERC721 spec: it does some checks, stores data about ownership, emits events and calls callbacks.
But you don’t want to allow anyone to call this function without any limitations. Here are the 5 most common things that developers add to their mint function:
1. Payments
To receive payments in the native currency (i.e. $ETH on Ethereum, $MATIC on Polygon, etc.), mark your mint
function as payable
and verify the amount is correct:
function mint(uint256 tokenId) external payable {
require(msg.value == 0.5 ether, 'Wrong price');
_safeMint(msg.sender, tokenId);
}
How do you get the money out though? The best practice is to add withdraw function to your contract that allows you to pull the funds out when needed:
function withdrawAll() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
2. Supply tracking
To make NFTs exclusive, most projects add a supply limit, e.g. “there can’t be more than 10K items”. The easiest way to do it is inside the mint function:
contract SimplestNFT is ERC721 {
uint256 public totalSupply;
function mint(uint256 tokenId) external payable {
require(totalSupply < 10_000, 'Sold out');
totalSupply++;
_safeMint(msg.sender, tokenId);
}
}
Be careful though. If you have multiple mint functions (e.g. publicMint, teamMint, etc.) make sure the limit is enforced in all of them.
Famously, BAYC contract doesn’t enforce the supply limit when called by developers, so they had to give up contract ownership.
3. Gating
Currently, anyone can call our mint function. This is fine, unless you work on a high profile drop. For popular projects, bots and automated scripts will try to snipe NPFs before humans can, causing massive gas war and frustrations to everyone except the clever hackers.
There are several ways one can limit who can call the mint function:
Check if the caller has another token. E.g. “only Loot owners can mint” or “holders of premint pass can mint”.
Check if the caller is part of a Merkle tree (also called Allow List, Whitelist, WL). The list of addresses is typically composed off-chain (e.g. via participating in raffles) and the root of the tree is posted on-chain. The contract can verify the address is part of the list without having to store the whole list (which would be very expensive).
Signed mints. This method is quickly gaining traction. The idea is to have a private wallet on a centralized server to sign parameters for the mint function. The server can check all the requirements off-chain and only sign mint requests that are valid. The contract simply verifies that the signature matches.
All these methods deserve a separate post. Especially signed mints, there’s a lot of ways to get it wrong. I’m working on the new post right now, so please sign up 😉
But here’s one thing NOT to do. Back in the old days developers used a simple gate like this:
require(msg.sender == tx.origin, 'No bots please'); // Don't do this
The idea was to only allow external wallets to mint (not smart contracts). But this causes more troubles that it’s worth. First, it’s not effective, as hackers can always spin up an unlimited amount of wallets. Second, this prevents smart wallets and multisig contracts from interacting with your NFT.
If bots are the concern, use proper gating to verify “humanness”.
4. Sale status
Here’s the simplest way to allow developers to control the sale status:
contract SimplestNFT is ERC721, Ownable {
bool public saleIsActive;
function mint(uint256 tokenId) external {
require(saleIsActive, 'Sale not active');
_safeMint(msg.sender, tokenId);
}
function setSaleState(bool active) external onlyOwner {
saleIsActive = active;
}
}
This is often used to start sales on a specific date and time. It’s also useful to pause the minting if something is wrong.
There are more complex ways to control the sale status. E.g. Rings (for Loot) has several minting stages, Zora’s Zorb only allows minting during hardcoded timestamps.
5. Actual minting (and avoiding re-entrancy bugs)
This is the place where the actual minting happens. The heavy lifting is usually done by the library you are using, but there are a few important details.
OpenZeppelin has 2 functions for this _mint
and _safeMint
. What’s the difference?
_mint
is the low level function that verifies that the token id is not taken yet, stores information about the owner and emits Transfer event._safeMint
calls_mint
under the hood, but it also checks if the minter is a contract, and if it is, it callsonERC721Received
callback.
Unless there’s a really good reason, projects should use _safeMint
.
There’s one detail about _safeMint
though. Consider the following code:
contract SimplestNFT is ERC721 {
mapping(address => bool) public hasMinted;
function mint(uint256 tokenId) external {
require(!hasMinted[msg.sender], 'already minted');
_safeMint(msg.sender, tokenId); // DON'T DO THIS HERE!
hasMinted[msg.sender] = true;
}
}
It tries to enforce that each address can mint only once (which is a bad idea for many reasons, but let’s run with it). The problem with this code is that if a malicious contract calls mint
, our contract will call the attacker’s onERC721Received
callback, which can call our mint
function again before it updates hasMinted
!
This bug allows the attacker to mint more tokens than we wanted them to.
contract Evil {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
if (tokenId < 100) {
SimpleNFT(msg.sender).mint(tokenId + 1);
}
return onERC721Received.selector;
};
}
To avoid this, many new developers use ReentrancyGuard
contract from OpenZeppelin and nonReentrant
modifier. Don’t do that! It makes minting more expensive, and there’s a very simple way of fixing the re-entrancy issue:
function mint(uint256 tokenId) external {
require(!hasMinted[msg.sender], 'already minted');
hasMinted[msg.sender] = true;
_safeMint(msg.sender, tokenId);
}
Note that we update hasMinted
right away, before potentially calling out to external contracts. Now, even if the Evil contract calls mint
again, it won’t mint them another token because hasMinted
for that address has already been set!
Summary
A good NFT contract mint function is structured like this:
Verify the parameters are correct: payment value, sale status, supply, etc.
Update the state: total supply, whenever signature was used, etc.
Call
_safeMint
to do the heavy lifting
Subscribe for more posts about building NFT contracts with Solidity